iT邦幫忙

2024 iThome 鐵人賽

DAY 19
1

讓我們來開發物理包裝器元件吧!

鱈魚:「第一步從認識碰撞偵測演算法開始!◝(≧∀≦)◟」

路人:「不會吧!╭(°A ,°`)╮」

鱈魚:「的確不會,因為我也不太會。ᕕ( ゚ ∀。)ᕗ 」

路人:「那你講屁講喔。…(›´ω`‹ )」

此元件中物理模擬的部分,讓我們使用 Matter.js 實現吧。

甚麼是 Matter.js

Matter.js 是一個相當老牌、成熟的 JS 2D 物理引擎,可以模擬質量、重力、碰撞、摩擦等等複雜的物理現象,更可以建構彈簧、關節等等各種複雜的複合元件。

官方網站有相當多有趣的例子,趕快去官網看看吧。( ´ ▽ ` )ノ

Matter.js 官網

那就讓我們安裝 Matter.js。

npm i -D matter-js @types/matter-js

現在讓我們開始開發吧!ヽ(●`∀´●)ノ

原理

概念為在 Matter.js 的物理事件中建立與目標 DOM 相同尺寸的物體並模擬物理效果,同時將對應元素之狀態同步至 DOM 元素上,就可以實現物理模擬。

如同下圖概念:

D19 (1).png

就像皮影戲偶一般,DOM 元素同步在後方 Matter 物體的動作。

建立物理世界

先來完成物理世界的部分,首先是 template。

src\components\wrapper-physics\wrapper-physics.vue

<template>
  <div
    ref="wrapperRef"
    class="wrapper-physics relative overflow-hidden"
  >
    <slot />

    <canvas
      ref="canvasRef"
      class=" absolute inset-0 pointer-events-none bg-transparent"
    />
  </div>
</template>

...

slot 用於放入物理模擬的元素,canvas 則讓我們在開發中 debug 使用,用來觀察 Matter 繪製的圖形是否正確。

接下來定義元件 props 內容。

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
...
import Matter from 'matter-js';

// #region Props
interface Props {
  /** 立即開始,物體會在元件建立完成後馬上會開始掉落 */
  immediate?: boolean;

  /** 重力加速度
   * 
   * x, y 為加速度的方向,scale 為加速度的大小
   */
  gravity?: Matter.Gravity;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
  immediate: false,
  gravity: () => ({
    scale: 0.001,
    x: 0,
    y: 1,
  }),
});

...
</script>

...

gravity 的資料結構直接照搬 Matter 的 gravity 型別。◝( •ω• )◟

接下來讓我們建立 Matter 的物理世界吧,需要以下物件:

  • Engine:負責模擬物理
  • Runner:執行、更新引擎

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
...

const {
  Engine, Runner,
} = Matter;

const props = withDefaults(defineProps<Props>(), {...});

const wrapperRef = ref<HTMLDivElement>();
const canvasRef = ref<HTMLCanvasElement>();

const engine = shallowRef(
  Engine.create({ gravity: props.gravity })
);
/** 同步 props.gravity 至 engine */
watch(() => props.gravity, (value) => {
  engine.value.gravity = value;
}, {
  immediate: true,
  deep: true
});

const runner = shallowRef(
  Runner.create()
);

...
</script>

...

這樣就可以建立一個 Matter 的物理世界了,是不是很簡單啊。( •̀ ω •́ )✧

現在讓我們建立一個方塊試試看會不會正常掉落。

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
...

/** 取得 DOM 尺寸,用於指定世界範圍 */
const wrapperBounding = reactive(
  useElementBounding(wrapperRef)
);

...

function init() {
  const { width, height } = wrapperBounding;

  const rect = Bodies.rectangle(50, 50, 100, 100);
  
  /** 把方塊加入引擎中 */
  Composite.add(engine.value.world, rect);

  const render = Render.create({
    canvas: canvasRef.value,
    engine: engine.value,
    bounds: {
      min: { x: 0, y: 0 },
      max: { x: width, y: height },
    },
    options: {
      width: width,
      height: height,
      background: 'transparent',
      wireframeBackground: 'transparent',
    },
  });
  /** 執行 render,將畫面畫到 canvas 中 */
  Render.run(render);

  /** 執行 runner,開始物理模擬 */
  Runner.run(runner.value, engine.value);
}

onMounted(() => {
  init();
});

...
</script>

...

調整一下 basic-usage 尺寸,不然畫面高度是 0,啥都看不到。

src\components\wrapper-physics\examples\basic-usage.vue

<template>
  <div ...>
    <wrapper-physics class="h-[50vh]" />
  </div>
</template>

...

現在可以看到畫面會有一個方塊掉落。

動畫.gif

不過一路掉到地心,一去不復返。(́⊙◞౪◟⊙‵)

讓我們在世界邊界加上圍牆吧。

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
...

function init() {
  ...
  
    // 在邊界建立牆壁
  const thickness = 100;
  const boundaries = [
    Bodies.rectangle(
      width / 2, -thickness / 2,
      width * 2, thickness,
      { isStatic: true, label: 'top' }
    ),
    Bodies.rectangle(
      width + thickness / 2, height / 2,
      thickness, height * 2,
      { isStatic: true, label: 'right' }
    ),
    Bodies.rectangle(
      width / 2, height + thickness / 2,
      width * 2, thickness,
      { isStatic: true, label: 'bottom' }
    ),
    Bodies.rectangle(
      -thickness / 2, height / 2,
      thickness, height * 2,
      { isStatic: true, label: 'left' }
    ),
  ];
  Composite.add(engine.value.world, boundaries);

  const render = Render.create(...);
  ...
}

...
</script>

...

動畫.gif

會發現方塊穩穩地停下來了。◝(≧∀≦)◟

物理模擬的部分沒問題了,接下來是 DOM 產生對應物體的部分。

建立物體

讓我們來建立物理世界中的物體吧,首先在 wrapper-physics-body 新增 template 內容。

src\components\wrapper-physics\wrapper-physics-body.vue

<template>
  <div ref="containerRef">
    <slot />
  </div>
</template>

...

內容非常簡單,就是一個包住 slot 的 div 即可。

接下來讓我們定義元件參數,基本上就是 Matter 的物體性質參數。

src\components\wrapper-physics\wrapper-physics-body.vue

...

<script setup lang="ts">
...

// #region Props
interface Props {
  /** 空氣阻力。物體在空氣中受到的阻力 */
  frictionAir?: number;
  /** 摩擦力。物體本身的摩擦力,必須為 0 ~ 1,0 表示持續滑動,1 表示受力後會立即停止 */
  friction?: number;
  /** 回彈力。碰撞的回彈係數,0 表示不反彈,1 表示完全反彈 */
  restitution?: number;
  /** 物體質量 */
  mass?: number;
  /** 靜止。會變成像地面那樣完全靜止的存在 */
  isStatic?: boolean;
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
  frictionAir: 0.01,
  friction: 0.1,
  restitution: 0.3,
  mass: undefined,
  isStatic: false,
});

...
</script>

...

程式邏輯也非常單純,物體只負責通知世界自身物體性質與管理自身的狀態。

先讓我們新增狀態的部分。

src\components\wrapper-physics\wrapper-physics-body.vue

<template>
  <div
    ref="containerRef"
    :style="style"
  >
    <slot />
  </div>
</template>

<script setup lang="ts">
...

/** 物體唯一 ID */
const id = crypto.randomUUID();

const containerRef = ref<HTMLDivElement>();
/** 取得容器尺寸與位置資訊 */
const containerBounding = reactive(
  useElementBounding(containerRef)
);

/** 物體狀態,例如:偏移、旋轉等等 */
const info = ref({
  offsetX: 0,
  offsetY: 0,
  rotate: 0,
});

const style = computed(() => {
  const {
    offsetX, offsetY, rotate
  } = info.value;

  return {
    transform: `translate(${offsetX}px, ${offsetY}px) rotate(${rotate}deg)`,
  }
});
</script>

現在我們把物理世界與物體都準備好了,接下來就是關鍵部分了。

所以物理世界要怎麼知道有那些物體?(́◉◞౪◟◉‵)

注入資訊吧!

這裡我們使用 Vue 的 Provide / Inject,這可以讓元件提供特定的 function 或變數給後代元件使用。

Vue 文件:Provide / Inject

過程如下:

  1. wrapper-physics 元件提供綁定 function 與物體狀態
  2. wrapper-physics-body 取用 wrapper-physics 的 function,綁定自身尺寸與 ID 等等資訊
  3. wrapper-physics 提供所有物體狀態
  4. wrapper-physics-body 根據 ID 取得自身狀態並同步至 style 中

如此我們就可以完成 DOM 元素物理模擬的效果了!◝( •ω• )◟

第一步先讓我們定義一下共用的型別與資料。

src\components\wrapper-physics\index.ts

import { InjectionKey } from 'vue';

/** 在物理世界中註冊的元素物體資訊 */
export interface ElBody {
  id: string;
  width: number;
  height: number;
  x: number;
  y: number;

  /** 初始值,用於計算偏移量 */
  initial: {
    offsetX: number;
    offsetY: number;
    rotate: number;
  },

  /** 空氣阻力。物體在空氣中受到的阻力 */
  frictionAir?: number;
  /** 摩擦力。物體本身的摩擦力,必須為 0 ~ 1,0 表示持續滑動,1 表示受力後會立即停止 */
  friction?: number;
  /** 回彈力。碰撞的回彈係數,0 表示不反彈,1 表示完全反彈 */
  restitution?: number;
  /** 物體質量 */
  mass?: number;
  /** 靜止。會變成像地面那樣完全靜止的存在 */
  isStatic?: boolean;
}

export interface ProvideContent {
  bindBody: (body: ElBody) => void;
  unbindBody: (id: string) => void;
  getInfo: (id: string) => {
    offsetX: number;
    offsetY: number;
    rotate: number;
  } | undefined;
}

export const PROVIDE_KEY = Symbol('wrapper-physics') as InjectionKey<ProvideContent>;

來新增 wrapper-physics 用於 Provide 的資料吧。

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
import { onMounted, provide, reactive, ref, shallowRef, watch } from 'vue';
import Matter from 'matter-js';
import { useElementBounding } from '@vueuse/core';
import { ElBody, PROVIDE_KEY } from '.';

const {
  Engine, Render, Runner, Bodies, Composite,
} = Matter;

...

/** 儲存已建立的 body */
const bodyMap = new Map<string, ElBody>();
/** body 物理模擬資料 */
const bodyInfoMap = new Map<string, {
  offsetX: number;
  offsetY: number;
  rotate: number;
}>();

/** 註冊 body */
function bindBody(item: ElBody) {
  bodyMap.set(item.id, item);
}
/** 解除 body */
function unbindBody(id: string) {
  bodyMap.delete(id);
  bodyInfoMap.delete(id);
}

provide(PROVIDE_KEY, {
  bindBody,
  unbindBody,
  getInfo(id) {
    return bodyInfoMap.get(id);
  },
});

const wrapperRef = ref<HTMLDivElement>();
...
</script>

現在讓 wrapper-physics-body 取得 provide 內容吧。

src\components\wrapper-physics\wrapper-physics-body.vue

...

<script setup lang="ts">
import { PROVIDE_KEY } from '.';

...

const style = computed(...);

const wrapper = inject(PROVIDE_KEY);
if (!wrapper) {
  console.warn('wrapper-physics-body 必須在 wrapper-physics 元件中使用');
}
</script>

沒錯,就這麼簡單。∠( ᐛ 」∠)_

新增綁定相關邏輯。

src\components\wrapper-physics\wrapper-physics-body.vue

...

<script setup lang="ts">
...

function bindBody() {
  wrapper?.bindBody({
    id,
    width: containerBounding.width,
    height: containerBounding.height,
    x: containerBounding.x,
    y: containerBounding.y,
    initial: {
      offsetX: 0,
      offsetY: 0,
      rotate: 0,
    },
    ...props,
  });
}

onMounted(() => {
  bindBody();
});
</script>

這樣 wrapper-physics-body 元件就會在 mounted 後呼叫 wrapper-physics 的 bindBody,將自身的資料傳遞至 wrapper-physics!(/≧▽≦)/

所以現在我們有物理世界內的物體了,讓我們實現最重要的步驟,也就是初始化對應的物體。

調整一下 wrapper-physics 的 init 內容。

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
...

/** 取得 DOM 尺寸,用於指定世界範圍 */
const wrapperBounding = reactive(...);
/** 物理世界座標初始值
 *
 * 以免畫面滾動後,重新建立物理世界時,物體位置不正確
 */
 let wrapperInitPosition = {
  x: 0,
  y: 0,
}
onMounted(() => {
  wrapperInitPosition = {
    x: wrapperBounding.x,
    y: wrapperBounding.y,
  }
});

...

function init() {
  const { width, height } = wrapperBounding;

  /** 根據 elBody 資料建立 Matter Body */
  const bodies = Array.from(bodyMap.values()).map((elBody) => {
    const { width, height } = elBody;

    /** 
     * el body 的 xy 是相對於網頁左上角為 0 點,
     * 所以要先減去 wrapper 的 x, y 來取得相對於
     * wrapper 的 x, y,再加上 width, height 的
     * 一半,偏移自身中心
     */
    const { x, y } = {
      x: elBody.x - wrapperInitPosition.x + width / 2,
      y: elBody.y - wrapperInitPosition.y + height / 2,
    }

    const {
      frictionAir, friction, restitution, mass, isStatic,
    } = elBody;

    const body = Bodies.rectangle(x, y, width, height, {
      frictionAir, friction, restitution, mass, isStatic,
      label: elBody.id,
    });

    // 更新初始值
    const data = bodyMap.get(elBody.id);
    if (data) {
      bodyMap.set(elBody.id, {
        ...data,
        initial: {
          offsetX: body.position.x,
          offsetY: body.position.y,
          rotate: body.angle,
        },
      });
    }

    return body;
  })
  /** 將所有 body 加入引擎中 */
  Composite.add(engine.value.world, bodies);

  // 在邊界建立牆壁
  ...
}

...
</script>

現在我們實現初始化物理世界內的物體了!╰(´︶`)╯

讓我們調整一下 basic-usage,試試看有沒有成功吧。

src\components\wrapper-physics\examples\basic-usage.vue

<template>
  <div class="flex flex-col gap-4 w-full border">
    <wrapper-physics class="h-[50vh] flex justify-center items-center">
      <wrapper-physics-body class="border p-1">
        安安
      </wrapper-physics-body>
    </wrapper-physics>
  </div>
</template>

<script setup lang="ts">
import WrapperPhysics from '../wrapper-physics.vue';
import WrapperPhysicsBody from '../wrapper-physics-body.vue';
</script>

新增一段文字試試看。

動畫.gif

可以看到物理世界中產生了一個與文字一樣的大的物體,而且會正常掉落!♪( ◜ω◝و(و

最後我們只要不斷同步 Matter 世界中的狀態至 Vue 的變數中就可以了。

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
...

function init() {...}

// 不斷將 Matter 狀態同步至 bodyInfoMap 中
const {
  pause: pauseUpdate,
  resume: resumeUpdate,
} = useIntervalFn(() => {
  const list = Composite.allBodies(engine.value.world);

  list.forEach((body) => {
    /** id 存在 label 中 */
    const id = body.label;
    const info = bodyMap.get(id);

    if (!bodyMap.has(id) || !info) {
      return;
    }

    const { initial } = info;
    const value = {
      ...{
        offsetX: body.position.x - initial.offsetX,
        offsetY: body.position.y - initial.offsetY,
      },
      rotate: body.angle * 180 / Math.PI,
    }

    bodyInfoMap.set(id, value);
  });
}, 10);

...
</script>

body 則不斷取出對應 ID 的資料。

src\components\wrapper-physics\wrapper-physics-body.vue

...

<script setup lang="ts">
...

/** 物體狀態,例如:偏移、旋轉等等 */
const info = ref(...);

/** 調整精度,小於 0.001 數值視為 0 */
function adjAccuracy(num: number) {
  return Math.abs(num) < 0.001 ? 0 : num;
}

useIntervalFn(() => {
  const newInfo = wrapper?.getInfo(id);
  if (!newInfo) {
    info.value = {
      offsetX: adjAccuracy(info.value.offsetX - info.value.offsetX * 0.05),
      offsetY: adjAccuracy(info.value.offsetY - info.value.offsetY * 0.05),
      rotate: adjAccuracy(info.value.rotate - info.value.rotate * 0.05),
    };
    return;
  }

  info.value = {
    offsetX: info.value.offsetX + (newInfo.offsetX - info.value.offsetX) * 0.8,
    offsetY: info.value.offsetY + (newInfo.offsetY - info.value.offsetY) * 0.8,
    rotate: info.value.rotate + (newInfo.rotate - info.value.rotate) * 0.8,
  };
}, 10);

...
</script>

同步狀態成功!✧⁑。٩(ˊᗜˋ*)و✧⁕。

動畫.gif

現在讓我們多加一點元素吧。

src\components\wrapper-physics\examples\basic-usage.vue

<template>
  <div class="flex flex-col gap-4 w-full border">
    <wrapper-physics class="h-[50vh] flex flex-col justify-center items-center">
      <wrapper-physics-body class="border p-1">
        安安安
      </wrapper-physics-body>
      <wrapper-physics-body class="border p-1 mr-1">
        安安
      </wrapper-physics-body>
      <wrapper-physics-body class="border p-1 mr-2">
        安
      </wrapper-physics-body>
    </wrapper-physics>
  </div>
</template>

<script setup lang="ts">
import WrapperPhysics from '../wrapper-physics.vue';
import WrapperPhysicsBody from '../wrapper-physics-body.vue';
</script>

效果很好!(/≧▽≦)/

動畫.gif

最後讓我們實作 wrapper-physics 元件的 immediate 參數,讓使用者可以選擇是否立即開始。

src\components\wrapper-physics\wrapper-physics.vue

...

<script setup lang="ts">
...

function init() {
  ...
  /** 執行 render,將畫面畫到 canvas 中 */
  Render.run(render);
}

function start() {
  Runner.run(runner.value, engine.value);
  resumeUpdate();
}
function clear() {
  Composite.clear(engine.value.world, true);
  Engine.clear(engine.value);
  Runner.stop(runner.value);

  bodyInfoMap.clear();
}
function reset() {
  clear();

  engine.value = Engine.create({
    gravity: props.gravity,
  });
  runner.value = Runner.create();
  init();
  pauseUpdate();
}

// 不斷將 Matter 狀態同步至 bodyInfoMap 中
const {
  pause: pauseUpdate,
  resume: resumeUpdate,
} = useIntervalFn(() => {
  const list = Composite.allBodies(engine.value.world);

  list.forEach((body) => {
    /** id 存在 label 中 */
    const id = body.label;
    const info = bodyMap.get(id);

    if (!bodyMap.has(id) || !info) {
      return;
    }

    const { initial } = info;
    const value = {
      ...{
        offsetX: body.position.x - initial.offsetX,
        offsetY: body.position.y - initial.offsetY,
      },
      rotate: body.angle * 180 / Math.PI,
    }

    bodyInfoMap.set(id, value);
  });
}, 10);

onMounted(() => {
  init();

  if (props.immediate) {
    start();
  }
});

onBeforeUnmount(() => {
  clear();
});

// #region Methods
defineExpose({
  /** 開始 */
  start,
  /** 重置所有元素,元素會回到初始位置 */
  reset,
});
// #endregion Methods
</script>

並在 basic-usage 新增開始與重置按鈕。

src\components\wrapper-physics\examples\basic-usage.vue

<template>
  <div class="flex flex-col gap-4 w-full border">
    <wrapper-physics
      ref="wrapperRef"
      class="h-[50vh] flex flex-col justify-center items-center"
    >
      <div class="flex gap-4 my-6">
        <wrapper-physics-body
          class="border p-2 px-6 cursor-pointer select-none"
          @click="wrapperRef?.start()"
        >
          開始
        </wrapper-physics-body>

        <wrapper-physics-body
          class="border p-2 px-6 cursor-pointer select-none"
          @click="wrapperRef?.reset()"
        >
          重置
        </wrapper-physics-body>
      </div>

      <wrapper-physics-body class="border p-1">
        安安安
      </wrapper-physics-body>
      ...
    </wrapper-physics>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

import WrapperPhysics from '../wrapper-physics.vue';
import WrapperPhysicsBody from '../wrapper-physics-body.vue';

const wrapperRef = ref<InstanceType<typeof WrapperPhysics>>();
</script>

動畫.gif

按下「開始」後才會開始物理模擬,「重置」則是可以讓所有元素回歸原位。

確定功能實現後,就不需要除錯用的 Render 了,讓我們刪除 Render 部分吧。ԅ( ˘ω˘ԅ)

src\components\wrapper-physics\wrapper-physics.vue

<template>
  <div
    ref="wrapperRef"
    class="wrapper-physics relative overflow-hidden"
  >
    <slot />
  </div>
</template>

<script setup lang="ts">
...

function init() {
  ...
  Composite.add(engine.value.world, boundaries);
}
...
</script>

動畫.gif

完成!✧⁑。٩(ˊᗜˋ*)و✧⁕。

有興趣的話也可以來這裡實際玩玩看喔!੭ ˙ᗜ˙ )੭

總結

  • 完成「物理包裝器」樣式
  • 完成「物理包裝器」邏輯
  • 完成 basic-usage 範例

以上程式碼已同步至 GitLab,大家可以前往下載:

GitLab - D19


上一篇
D18 - 物理包裝器:分析需求
下一篇
D20 - 物理包裝器:單元測試
系列文
要不要 Vue 點酷酷的元件?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

2
Sunny.Cat
iT邦新手 3 級 ‧ 2024-10-05 02:37:50

這篇也好有趣

鱈魚 iT邦研究生 5 級 ‧ 2024-10-05 11:50:19 檢舉

感謝彭場 (/≧▽≦)/

我要留言

立即登入留言